2 * MediaWiki Widgets – CalendarWidget class.
4 * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
11 * Creates an mw.widgets.CalendarWidget object.
13 * You will most likely want to use mw.widgets.DateInputWidget instead of CalendarWidget directly.
16 * @extends OO.ui.Widget
17 * @mixins OO.ui.mixin.TabIndexedElement
18 * @mixins OO.ui.mixin.FloatableElement
21 * @param {Object} [config] Configuration options
22 * @cfg {boolean} [lazyInitOnToggle=false] Don't build most of the interface until
23 * `.toggle( true )` is called. Meant to be used when the calendar is not immediately visible.
24 * @cfg {string} [precision='day'] Date precision to use, 'day' or 'month'
25 * @cfg {string|null} [date=null] Day or month date (depending on `precision`), in the format
26 * 'YYYY-MM-DD' or 'YYYY-MM'. When null, the calendar will show today's date, but not select
29 mw
.widgets
.CalendarWidget
= function MWWCalendarWidget( config
) {
30 // Config initialization
31 config
= config
|| {};
34 mw
.widgets
.CalendarWidget
.parent
.call( this, config
);
37 OO
.ui
.mixin
.TabIndexedElement
.call( this, $.extend( {}, config
, { $tabIndexed
: this.$element
} ) );
38 OO
.ui
.mixin
.FloatableElement
.call( this, config
);
41 this.lazyInitOnToggle
= !!config
.lazyInitOnToggle
;
42 this.precision
= config
.precision
|| 'day';
43 // Currently selected date (day or month)
45 // Current UI state (date and precision we're displaying right now)
47 this.displayLayer
= this.getDisplayLayers()[ 0 ]; // 'month', 'year', 'duodecade'
49 this.$header
= $( '<div>' ).addClass( 'mw-widget-calendarWidget-header' );
50 this.$bodyOuterWrapper
= $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-outer-wrapper' );
51 this.$bodyWrapper
= $( '<div>' ).addClass( 'mw-widget-calendarWidget-body-wrapper' );
52 this.$body
= $( '<div>' ).addClass( 'mw-widget-calendarWidget-body' );
56 focus
: this.onFocus
.bind( this ),
57 mousedown
: this.onClick
.bind( this ),
58 keydown
: this.onKeyDown
.bind( this )
63 .addClass( 'mw-widget-calendarWidget' )
64 .append( this.$header
, this.$bodyOuterWrapper
.append( this.$bodyWrapper
.append( this.$body
) ) );
65 if ( !this.lazyInitOnToggle
) {
66 this.buildHeaderButtons();
68 this.setDate( config
.date
!== undefined ? config
.date
: null );
73 OO
.inheritClass( mw
.widgets
.CalendarWidget
, OO
.ui
.Widget
);
74 OO
.mixinClass( mw
.widgets
.CalendarWidget
, OO
.ui
.mixin
.TabIndexedElement
);
75 OO
.mixinClass( mw
.widgets
.CalendarWidget
, OO
.ui
.mixin
.FloatableElement
);
82 * A change event is emitted when the chosen date changes.
84 * @param {string} date Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'
90 * Get the date format ('YYYY-MM-DD' or 'YYYY-MM', depending on precision), which is used
91 * internally and for dates accepted by #setDate and returned by #getDate.
94 * @return {string} Format
96 mw
.widgets
.CalendarWidget
.prototype.getDateFormat = function () {
104 * Get the date precision this calendar uses, 'day' or 'month'.
107 * @return {string} Precision, 'day' or 'month'
109 mw
.widgets
.CalendarWidget
.prototype.getPrecision = function () {
110 return this.precision
;
114 * Get list of possible display layers.
117 * @return {string[]} Layers
119 mw
.widgets
.CalendarWidget
.prototype.getDisplayLayers = function () {
120 return [ 'month', 'year', 'duodecade' ].slice( this.precision
=== 'month' ? 1 : 0 );
124 * Update the calendar.
127 * @param {string|null} [fade=null] Direction in which to fade out current calendar contents,
128 * 'previous', 'next', 'up' or 'down'; or 'auto', which has the same result as 'previous' or
129 * 'next' depending on whether the current date is later or earlier than the previous.
131 mw
.widgets
.CalendarWidget
.prototype.updateUI = function ( fade
) {
132 var items
, today
, selected
, currentMonth
, currentYear
, currentDay
, i
, needsFade
,
133 $bodyWrapper
= this.$bodyWrapper
;
135 if ( this.lazyInitOnToggle
) {
136 // We're being called from the constructor and not being shown yet, do nothing
141 this.displayLayer
=== this.previousDisplayLayer
&&
142 this.date
=== this.previousDate
&&
143 this.previousMoment
&&
144 this.previousMoment
.isSame( this.moment
, this.precision
=== 'month' ? 'month' : 'day' )
150 if ( fade
=== 'auto' ) {
151 if ( !this.previousMoment
) {
153 } else if ( this.previousMoment
.isBefore( this.moment
, this.precision
=== 'month' ? 'month' : 'day' ) ) {
155 } else if ( this.previousMoment
.isAfter( this.moment
, this.precision
=== 'month' ? 'month' : 'day' ) ) {
163 if ( this.$oldBody
) {
164 this.$oldBody
.remove();
166 this.$oldBody
= this.$body
.addClass( 'mw-widget-calendarWidget-old-body' );
167 // Clone without children
168 this.$body
= $( this.$body
[ 0 ].cloneNode( false ) )
169 .removeClass( 'mw-widget-calendarWidget-old-body' )
170 .toggleClass( 'mw-widget-calendarWidget-body-month', this.displayLayer
=== 'month' )
171 .toggleClass( 'mw-widget-calendarWidget-body-year', this.displayLayer
=== 'year' )
172 .toggleClass( 'mw-widget-calendarWidget-body-duodecade', this.displayLayer
=== 'duodecade' );
175 selected
= moment( this.getDate(), this.getDateFormat() );
177 switch ( this.displayLayer
) {
179 this.labelButton
.setLabel( this.moment
.format( 'MMMM YYYY' ) );
180 this.upButton
.toggle( true );
182 // First week displayed is the first week spanned by the month, unless it begins on Monday, in
183 // which case first week displayed is the previous week. This makes the calendar "balanced"
184 // and also neatly handles 28-day February sometimes spanning only 4 weeks.
185 currentDay
= moment( this.moment
).startOf( 'month' ).subtract( 1, 'day' ).startOf( 'week' );
187 // Day-of-week labels. Localisation-independent: works with weeks starting on Saturday, Sunday
189 for ( i
= 0; i
< 7; i
++ ) {
192 .addClass( 'mw-widget-calendarWidget-day-heading' )
193 .text( currentDay
.format( 'dd' ) )
195 currentDay
.add( 1, 'day' );
197 currentDay
.subtract( 7, 'days' );
199 // Actual calendar month. Always displays 6 weeks, for consistency (months can span 4 to 6
201 for ( i
= 0; i
< 42; i
++ ) {
204 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-day' )
205 .toggleClass( 'mw-widget-calendarWidget-day-additional', !currentDay
.isSame( this.moment
, 'month' ) )
206 .toggleClass( 'mw-widget-calendarWidget-day-today', currentDay
.isSame( today
, 'day' ) )
207 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentDay
.isSame( selected
, 'day' ) )
208 .text( currentDay
.format( 'D' ) )
209 .data( 'date', currentDay
.date() )
210 .data( 'month', currentDay
.month() )
211 .data( 'year', currentDay
.year() )
213 currentDay
.add( 1, 'day' );
218 this.labelButton
.setLabel( this.moment
.format( 'YYYY' ) );
219 this.upButton
.toggle( true );
221 currentMonth
= moment( this.moment
).startOf( 'year' );
222 for ( i
= 0; i
< 12; i
++ ) {
225 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-month' )
226 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentMonth
.isSame( selected
, 'month' ) )
227 .text( currentMonth
.format( 'MMMM' ) )
228 .data( 'month', currentMonth
.month() )
230 currentMonth
.add( 1, 'month' );
232 // Shuffle the array to display months in columns rather than rows:
240 items
[ 0 ], items
[ 6 ],
241 items
[ 1 ], items
[ 7 ],
242 items
[ 2 ], items
[ 8 ],
243 items
[ 3 ], items
[ 9 ],
244 items
[ 4 ], items
[ 10 ],
245 items
[ 5 ], items
[ 11 ]
250 this.labelButton
.setLabel( null );
251 this.upButton
.toggle( false );
253 currentYear
= moment( { year
: Math
.floor( this.moment
.year() / 20 ) * 20 } );
254 for ( i
= 0; i
< 20; i
++ ) {
257 .addClass( 'mw-widget-calendarWidget-item mw-widget-calendarWidget-year' )
258 .toggleClass( 'mw-widget-calendarWidget-item-selected', currentYear
.isSame( selected
, 'year' ) )
259 .text( currentYear
.format( 'YYYY' ) )
260 .data( 'year', currentYear
.year() )
262 currentYear
.add( 1, 'year' );
267 this.$body
.append
.apply( this.$body
, items
);
270 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-up' )
271 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-down' )
272 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-previous' )
273 .removeClass( 'mw-widget-calendarWidget-body-wrapper-fade-next' );
275 needsFade
= this.previousDisplayLayer
!== this.displayLayer
;
276 if ( this.displayLayer
=== 'month' ) {
277 needsFade
= needsFade
|| !this.moment
.isSame( this.previousMoment
, 'month' );
278 } else if ( this.displayLayer
=== 'year' ) {
279 needsFade
= needsFade
|| !this.moment
.isSame( this.previousMoment
, 'year' );
280 } else if ( this.displayLayer
=== 'duodecade' ) {
281 needsFade
= needsFade
|| (
282 Math
.floor( this.moment
.year() / 20 ) * 20 !==
283 Math
.floor( this.previousMoment
.year() / 20 ) * 20
287 if ( fade
&& needsFade
) {
288 this.$oldBody
.find( '.mw-widget-calendarWidget-item-selected' )
289 .removeClass( 'mw-widget-calendarWidget-item-selected' );
290 if ( fade
=== 'previous' || fade
=== 'up' ) {
291 this.$body
.insertBefore( this.$oldBody
);
292 } else if ( fade
=== 'next' || fade
=== 'down' ) {
293 this.$body
.insertAfter( this.$oldBody
);
295 setTimeout( function () {
296 $bodyWrapper
.addClass( 'mw-widget-calendarWidget-body-wrapper-fade-' + fade
);
299 this.$oldBody
.replaceWith( this.$body
);
302 this.previousMoment
= moment( this.moment
);
303 this.previousDisplayLayer
= this.displayLayer
;
304 this.previousDate
= this.date
;
306 this.$body
.on( 'click', this.onBodyClick
.bind( this ) );
310 * Construct and display buttons to navigate the calendar.
314 mw
.widgets
.CalendarWidget
.prototype.buildHeaderButtons = function () {
315 this.labelButton
= new OO
.ui
.ButtonWidget( {
319 classes
: [ 'mw-widget-calendarWidget-labelButton' ]
321 this.upButton
= new OO
.ui
.ButtonWidget( {
325 classes
: [ 'mw-widget-calendarWidget-upButton' ]
327 this.prevButton
= new OO
.ui
.ButtonWidget( {
331 classes
: [ 'mw-widget-calendarWidget-prevButton' ]
333 this.nextButton
= new OO
.ui
.ButtonWidget( {
337 classes
: [ 'mw-widget-calendarWidget-nextButton' ]
340 this.labelButton
.connect( this, { click
: 'onUpButtonClick' } );
341 this.upButton
.connect( this, { click
: 'onUpButtonClick' } );
342 this.prevButton
.connect( this, { click
: 'onPrevButtonClick' } );
343 this.nextButton
.connect( this, { click
: 'onNextButtonClick' } );
346 this.prevButton
.$element
,
347 this.nextButton
.$element
,
348 this.upButton
.$element
,
349 this.labelButton
.$element
354 * Handle click events on the "up" button, switching to less precise view.
358 mw
.widgets
.CalendarWidget
.prototype.onUpButtonClick = function () {
360 layers
= this.getDisplayLayers(),
361 currentLayer
= layers
.indexOf( this.displayLayer
);
362 if ( currentLayer
!== layers
.length
- 1 ) {
364 this.displayLayer
= layers
[ currentLayer
+ 1 ];
365 this.updateUI( 'up' );
372 * Handle click events on the "previous" button, switching to previous pane.
376 mw
.widgets
.CalendarWidget
.prototype.onPrevButtonClick = function () {
377 switch ( this.displayLayer
) {
379 this.moment
.subtract( 1, 'month' );
382 this.moment
.subtract( 1, 'year' );
385 this.moment
.subtract( 20, 'years' );
388 this.updateUI( 'previous' );
392 * Handle click events on the "next" button, switching to next pane.
396 mw
.widgets
.CalendarWidget
.prototype.onNextButtonClick = function () {
397 switch ( this.displayLayer
) {
399 this.moment
.add( 1, 'month' );
402 this.moment
.add( 1, 'year' );
405 this.moment
.add( 20, 'years' );
408 this.updateUI( 'next' );
412 * Handle click events anywhere in the body of the widget, which contains the matrix of days,
413 * months or years to choose. Maybe change the pane or switch to more precise view, depending on
417 * @param {jQuery.Event} e Click event
419 mw
.widgets
.CalendarWidget
.prototype.onBodyClick = function ( e
) {
421 $target
= $( e
.target
),
422 layers
= this.getDisplayLayers(),
423 currentLayer
= layers
.indexOf( this.displayLayer
);
424 if ( $target
.data( 'year' ) !== undefined ) {
425 this.moment
.year( $target
.data( 'year' ) );
427 if ( $target
.data( 'month' ) !== undefined ) {
428 this.moment
.month( $target
.data( 'month' ) );
430 if ( $target
.data( 'date' ) !== undefined ) {
431 this.moment
.date( $target
.data( 'date' ) );
433 if ( currentLayer
=== 0 ) {
434 this.setDateFromMoment();
435 this.updateUI( 'auto' );
438 this.displayLayer
= layers
[ currentLayer
- 1 ];
439 this.updateUI( 'down' );
446 * @param {string|null} [date=null] Day or month date, in the format 'YYYY-MM-DD' or 'YYYY-MM'.
447 * When null, the calendar will show today's date, but not select it. When invalid, the date
450 mw
.widgets
.CalendarWidget
.prototype.setDate = function ( date
) {
451 var mom
= date
!== null ? moment( date
, this.getDateFormat() ) : moment();
452 if ( mom
.isValid() ) {
454 if ( date
!== null ) {
455 this.setDateFromMoment();
456 } else if ( this.date
!== null ) {
458 this.emit( 'change', this.date
);
460 this.displayLayer
= this.getDisplayLayers()[ 0 ];
466 * Reset the user interface of this widget to reflect selected date.
468 mw
.widgets
.CalendarWidget
.prototype.resetUI = function () {
469 this.moment
= this.getDate() !== null ? moment( this.getDate(), this.getDateFormat() ) : moment();
470 this.displayLayer
= this.getDisplayLayers()[ 0 ];
475 * Set the date from moment object.
479 mw
.widgets
.CalendarWidget
.prototype.setDateFromMoment = function () {
480 // Switch to English locale to avoid number formatting. We want the internal value to be
481 // '2015-07-24' and not '٢٠١٥-٠٧-٢٤' even if the UI language is Arabic.
482 var newDate
= moment( this.moment
).locale( 'en' ).format( this.getDateFormat() );
483 if ( this.date
!== newDate
) {
485 this.emit( 'change', this.date
);
490 * Get current date, in the format 'YYYY-MM-DD' or 'YYYY-MM', depending on precision. Digits will
493 * @return {string|null} Date string
495 mw
.widgets
.CalendarWidget
.prototype.getDate = function () {
500 * Handle focus events.
504 mw
.widgets
.CalendarWidget
.prototype.onFocus = function () {
505 this.displayLayer
= this.getDisplayLayers()[ 0 ];
506 this.updateUI( 'down' );
510 * Handle mouse click events.
513 * @param {jQuery.Event} e Mouse click event
514 * @return {boolean} False to cancel the default event
516 mw
.widgets
.CalendarWidget
.prototype.onClick = function ( e
) {
517 if ( !this.isDisabled() && e
.which
=== 1 ) {
518 // Prevent unintended focussing
524 * Handle key down events.
527 * @param {jQuery.Event} e Key down event
528 * @return {boolean} False to cancel the default event
530 mw
.widgets
.CalendarWidget
.prototype.onKeyDown = function ( e
) {
532 dir
= OO
.ui
.Element
.static.getDir( this.$element
),
533 nextDirectionKey
= dir
=== 'ltr' ? OO
.ui
.Keys
.RIGHT
: OO
.ui
.Keys
.LEFT
,
534 prevDirectionKey
= dir
=== 'ltr' ? OO
.ui
.Keys
.LEFT
: OO
.ui
.Keys
.RIGHT
,
537 if ( !this.isDisabled() ) {
539 case prevDirectionKey
:
540 this.moment
.subtract( 1, this.precision
=== 'month' ? 'month' : 'day' );
542 case nextDirectionKey
:
543 this.moment
.add( 1, this.precision
=== 'month' ? 'month' : 'day' );
546 this.moment
.subtract( 1, this.precision
=== 'month' ? 'month' : 'week' );
548 case OO
.ui
.Keys
.DOWN
:
549 this.moment
.add( 1, this.precision
=== 'month' ? 'month' : 'week' );
551 case OO
.ui
.Keys
.PAGEUP
:
552 this.moment
.subtract( 1, this.precision
=== 'month' ? 'year' : 'month' );
554 case OO
.ui
.Keys
.PAGEDOWN
:
555 this.moment
.add( 1, this.precision
=== 'month' ? 'year' : 'month' );
563 this.displayLayer
= this.getDisplayLayers()[ 0 ];
564 this.setDateFromMoment();
565 this.updateUI( 'auto' );
574 mw
.widgets
.CalendarWidget
.prototype.toggle = function ( visible
) {
575 if ( this.lazyInitOnToggle
&& visible
) {
576 this.lazyInitOnToggle
= false;
577 this.buildHeaderButtons();
582 mw
.widgets
.CalendarWidget
.parent
.prototype.toggle
.call( this, visible
);
584 if ( this.$floatableContainer
) {
585 this.togglePositioning( this.isVisible() );
591 }( jQuery
, mediaWiki
) );